Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 34 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ on:
workflow_dispatch:
inputs:
release_tag:
description: Existing release tag to receive rebuilt packages (leave blank for a rehearsal)
description: Existing tag to stage as a verified draft (leave blank for a rehearsal)
required: false
type: string
release:
types: [published]

permissions:
contents: read

# Never run two writers for the same draft concurrently. Publication is a
# separate, explicit draft-to-public transition and does not trigger a build.
concurrency:
group: native-release-${{ inputs.release_tag || github.ref }}
cancel-in-progress: false

jobs:
browser-matrix:
name: Browser matrix (Linux)
Expand All @@ -22,7 +26,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }}
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
Expand Down Expand Up @@ -75,7 +79,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }}
ref: ${{ github.sha }}

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -86,7 +90,7 @@ jobs:
- name: Verify release version
shell: bash
env:
CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}
CYBERSNAPPER_RELEASE_TAG: ${{ inputs.release_tag }}
run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG"

- uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1
Expand Down Expand Up @@ -213,7 +217,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }}
ref: ${{ github.sha }}

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -224,7 +228,7 @@ jobs:
- name: Verify release version
shell: bash
env:
CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}
CYBERSNAPPER_RELEASE_TAG: ${{ inputs.release_tag }}
run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG"

- uses: ilammy/msvc-dev-cmd@v1
Expand Down Expand Up @@ -340,7 +344,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }}
ref: ${{ github.sha }}

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -351,7 +355,7 @@ jobs:
- name: Verify release version
shell: bash
env:
CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}
CYBERSNAPPER_RELEASE_TAG: ${{ inputs.release_tag }}
run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG"

- uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1
Expand Down Expand Up @@ -477,7 +481,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }}
ref: ${{ github.sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: CyberSnapper-linux-x64
Expand Down Expand Up @@ -527,7 +531,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }}
ref: ${{ github.sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: CyberSnapper-linux-x64
Expand Down Expand Up @@ -577,7 +581,7 @@ jobs:
xvfb.log

publish:
name: Assemble, verify, and optionally publish
name: Assemble, verify, and optionally stage draft
needs: [linux, windows, macos, appimage-compatibility, appimage-catalog]
runs-on: ubuntu-latest
permissions:
Expand All @@ -588,7 +592,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }}
ref: ${{ github.sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts
Expand All @@ -599,26 +603,33 @@ jobs:
run: python3 scripts/check-release-assets.py artifacts

- name: Preserve verified release rehearsal
if: github.event_name == 'workflow_dispatch' && inputs.release_tag == ''
if: inputs.release_tag == ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: CyberSnapper-release-rehearsal
path: artifacts/*
if-no-files-found: error

- name: Verify exact tag and draft before attesting
if: inputs.release_tag != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_COMMIT: ${{ github.sha }}
run: python3 scripts/stage-draft-release.py artifacts --tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT" --check-only

- name: Attest package provenance
if: github.event_name == 'release' || inputs.release_tag != ''
if: inputs.release_tag != ''
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: artifacts/*

- name: Publish packages to release
if: github.event_name == 'release' || inputs.release_tag != ''
shell: bash
- name: Stage complete verified draft without replacing assets
if: inputs.release_tag != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}"
gh release upload "$tag" artifacts/* --clobber
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_COMMIT: ${{ github.sha }}
run: python3 scripts/stage-draft-release.py artifacts --tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT"
58 changes: 48 additions & 10 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,40 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup
3. Create an annotated tag on that commit, for example `git tag -a v2.2.2 -m "CyberSnapper 2.2.2"`, and push the tag.
4. Treat a pushed release tag as immutable. Never move or replace a published `v*` tag; ship a new patch version if tagged source needs a code change.

## 4. Publish

1. Create a draft GitHub release for the existing tag and use the matching `docs/releases/` file as its release notes.
2. Publish the release. The `release: published` event starts **Native Release**, which builds from the release tag and attaches all packages, checksums, and attestations.
3. Do not use a nonblank manual `release_tag` for the normal publication path; it is reserved for recovery of an existing release.
## 4. Stage verified packages, then publish

1. Dispatch **Native Release** at the exact tag, with that same tag as the input:

```bash
gh workflow run release.yml --ref vMAJOR.MINOR.PATCH -f release_tag=vMAJOR.MINOR.PATCH
```

All candidate checkouts use the dispatch's immutable commit SHA. The tag,
workflow ref, checked-out source, and declared version must agree. Dispatching
a branch with a different `release_tag` is rejected.
2. Wait for every native package, browser, runtime, catalog, and assembly gate.
The final job validates all fourteen assets and checksums, creates provenance
attestations, then creates or resumes a **draft** release using the committed
notes. It never publishes a release, overwrites an asset, or deletes a file.
3. Require successful staging and independently confirm the draft contains all
fifteen files (fourteen assets plus `SHA256SUMS.txt`). Every remote file must
be uploaded and match the checked local size and SHA-256 digest. Verify the
tag still resolves to the rehearsed commit and provenance identifies it.
4. Do not publish while any staging run is active. Workflow concurrency
serializes staging runs for a tag, but cannot lock out a maintainer publishing
from another session. After all checks pass, explicitly publish the complete
draft:

```bash
gh release edit vMAJOR.MINOR.PATCH --draft=false --latest
```

For prereleases, keep the prerelease flag and do not mark the release latest.
Publication does **not** trigger a rebuild or replace the verified files.

## 5. Verify before announcing

1. Require the complete release workflow to pass, including all six native builds and the publish job.
1. Require the complete release workflow to pass, including all six native builds and the draft-staging job.
2. Confirm the release contains all of the following, plus `SHA256SUMS.txt`, with provenance attestations visible in GitHub:

- `CyberSnapper-linux-x64.AppImage`, its `.zsync` sidecar, and `CyberSnapper-linux-x64.tar.gz`
Expand All @@ -51,10 +76,23 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup

## Recovery

- For a transient runner or upload failure, rerun the failed release-workflow jobs against the same tag.
- If valid packages were built but assets were not attached correctly, manually dispatch **Native Release** with the existing `release_tag`; verify the rebuilt assets and checksums again.
- If the tagged source or a packaged application is defective, do not move the tag or silently replace the release. Document the issue and publish a corrected patch release from a new commit and tag.
- Keep an incomplete release unannounced until recovery succeeds. If downloads may be unsafe or misleading, mark the release as a prerelease while preparing the corrective release.
- A failed build leaves the public release unchanged. Fix any source defect in a
new candidate commit and repeat CI and the full rehearsal before tagging.
- For a transient draft-upload failure, rerun only the failed assembly/staging
job of the same workflow run. Its retained platform artifacts preserve the
exact bytes. Matching existing draft assets are retained and missing files
are uploaded; a full rebuild may produce different package bytes and is not
a substitute for resuming those artifacts.
- A conflicting, incomplete (`starter`), zero-byte, duplicate, unexpected, or
digest-less remote asset blocks staging. Nothing is deleted automatically.
Inspect the draft and obtain explicit approval for any destructive cleanup;
never publish a partial draft to work around a failure.
- Published releases are read-only to this workflow. If tagged source or a
published package is defective, document the issue and ship a corrected patch
version from a new commit and tag. Do not move a published `v*` tag or silently
overwrite its assets.
- Keep workflow artifacts and validation evidence until public download and
provenance verification are complete. Never announce a release before that.

## v2.4.2

Expand Down
71 changes: 44 additions & 27 deletions docs/PACKAGING.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# Packaging and publishing

This document describes *how* CyberSnapper's release packages are built and
published. For the step-by-step release *process* (rehearse → tag → publish →
verify), see [RELEASE.md](../RELEASE.md). For local builds and the install
published. For the step-by-step release *process* (rehearse → tag → stage →
publish → verify), see [RELEASE.md](../RELEASE.md). For local builds and the install
tree, see [BUILDING.md](BUILDING.md).

Everything runs in one workflow: `.github/workflows/release.yml` ("Native
Release"). Each package is produced on a GitHub Actions runner for its target
platform, smoke-tested end to end, then attached to the GitHub release together
with checksums and provenance attestations.
platform, smoke-tested end to end, then staged on a draft GitHub release together
with checksums and provenance attestations. Publication is a separate explicit
action after the complete draft passes verification.

## Package matrix

Expand All @@ -34,20 +35,23 @@ The workflow has native package jobs and independent validation gates:
- `macos` — matrix over x64 and arm64.
- `appimage-compatibility` — audits the exact x64 AppImage and opens its GUI on Ubuntu 22.04 and 24.04 without a Qt SDK.
- `appimage-catalog` — runs the pinned upstream AppImageHub worker on the exact x64 candidate.
- `publish` — waits for every package and compatibility gate, validates the complete asset set, then preserves a rehearsal bundle or publishes an authorized release.
- `publish` — waits for every package and compatibility gate, validates the complete asset set, then preserves a rehearsal bundle or stages an attested draft.

Triggers:
Trigger:

- `release: [published]` — builds from the published release tag and attaches
the packages to that release. This is the normal publication path.
- `workflow_dispatch` with an optional `release_tag` input — a blank tag is a
*rehearsal* (packages are uploaded as workflow artifacts, no release is
touched); a nonblank tag is the *recovery* path for rebuilding and
re-attaching an existing release.
- `workflow_dispatch` with an optional `release_tag` input. A blank tag is a
rehearsal: packages are uploaded as workflow artifacts, no release is touched.
- A nonblank tag stages packages on a draft only. Dispatch at that exact tag,
for example `gh workflow run release.yml --ref v2.4.2 -f release_tag=v2.4.2`.
The final job rejects a mismatched workflow ref, commit, tag, or version.
- There is no `release: published` trigger. Publishing an already verified draft
neither rebuilds nor overwrites its assets.

The `checkout` step selects `ref` from the release tag, the dispatch tag, or
the branch — in that order. The workflow definition itself is loaded from the
dispatched branch, while the built source comes from the selected ref.
Every candidate checkout uses `github.sha`, including the independent runtime,
catalog, and assembly gates. The catalog worker retains its separate pinned
upstream commit. Staging runs for one tag are serialized without canceling an
active run. Maintainers must wait for successful staging before publishing;
GitHub has no atomic "upload only if still draft" operation.

## Common build steps

Expand Down Expand Up @@ -170,16 +174,27 @@ from the runner's full Qt install.
Because this step runs before `upload-artifact`, a package that cannot actually
launch Chromium never reaches the release.

## Publishing
## Draft staging and publication

When all platform jobs succeed and a release tag is in play, the `publish` job:

1. Downloads all uploaded artifacts (merged into one directory).
2. Requires exactly twelve nonempty native packages plus two matching AppImage zsync sidecars, then writes `SHA256SUMS.txt` from all fourteen assets.
3. Records a GitHub/Sigstore build-provenance attestation over the artifacts.
4. Uploads everything to the release with
`gh release upload "$tag" artifacts/* --clobber`. The job has no checkout,
so it sets `GH_REPO` to point `gh` at the repository explicitly.
1. Downloads the exact run's platform artifacts into one directory.
2. Requires exactly twelve nonempty native packages plus two matching AppImage
zsync sidecars, then writes `SHA256SUMS.txt` from all fourteen assets.
3. Verifies the exact dispatch ref/commit, source version, tag target, local
checksums and sidecars, and any existing draft assets before attesting.
4. Records GitHub/Sigstore build-provenance attestations over all fifteen files.
5. Runs `scripts/stage-draft-release.py` with the same tag and commit. The helper
creates or resumes a draft, uploads only missing files without `--clobber`,
and checks every remote name, state, size, and SHA-256 digest. It rechecks
draft status before each upload and after verification. It refuses published
releases, conflicting/incomplete assets, and a moved tag.

The job retains its existing `contents: write`, `id-token: write`, and
`attestations: write` permissions; it needs no additional secret or token scope.
Nothing in the workflow publishes a release. A maintainer explicitly changes
only the verified draft's publication state after the run succeeds; the same
attested bytes then become public. See [RELEASE.md](../RELEASE.md).

## Signing

Expand All @@ -191,11 +206,13 @@ download. No paid signing identity is required.

## Recovery

If a package job fails after a tag is already published, re-dispatch the
workflow with the same nonblank `release_tag`; the build jobs check out that
tag and the publish job re-attaches with `--clobber`. Do not move a published
`v*` tag — ship a new patch version if tagged source needs a code change. See
the Recovery section of [RELEASE.md](../RELEASE.md) for the full policy.
Resume a failed draft-staging job within the same run to reuse its exact retained
artifacts. Matching files are skipped; missing files are uploaded. A conflicting
or incomplete remote asset fails safely without deletion, so any cleanup needs
explicit review. Do not rerun packaging and assume its bytes will be identical,
and do not publish while staging is active. Published assets cannot be changed
by this workflow; use a new patch version for a defective public release. Never
move a published `v*` tag. See [RELEASE.md](../RELEASE.md) for the full policy.

## AppImage updates and catalog submission

Expand Down
Loading
Loading