From 103a78e515a1587b3a30e2e67686f8255ec7e2c8 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:22:03 -0400 Subject: [PATCH 1/2] ci(release): dispatch the image build for a tag that already has a release A published release is immutable, so a failed image build cannot be retried by re-publishing the release that triggered it. Add workflow_dispatch with a tag input, refuse a tag that is not a relay release, and read the prerelease from the version when the event carries no release flags. The gate test learns the new clause form and throws on any clause it cannot translate, so a future change to the condition fails the test instead of passing silently. --- .changeset/release-dispatch-rerun.md | 16 +++++ .github/workflows/release.yml | 34 +++++++++-- test/unit/ci/publish-container-image.spec.ts | 62 +++++++++++++++----- 3 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 .changeset/release-dispatch-rerun.md diff --git a/.changeset/release-dispatch-rerun.md b/.changeset/release-dispatch-rerun.md new file mode 100644 index 00000000..a3dbcbf8 --- /dev/null +++ b/.changeset/release-dispatch-rerun.md @@ -0,0 +1,16 @@ +--- +"nostream": patch +"nostream-website": patch +--- + +ci(release): allow the image build to be dispatched for an existing tag + +A published release is immutable, so when the image build fails while publishing +it, the release cannot be re-published to retry: GitHub refuses the round trip +with `immutable cannot be changed when release is immutable`. That left #964's +release with no image and no way to build one. + +`release.yml` now accepts `workflow_dispatch` with a `tag` input, and refuses a +tag that is not a relay release, since the event carries no tag of its own. With +no release flags present the prerelease is read from the version, so a dispatch +of `nostream@3.8.0-rc.1` still leaves `latest` where it is. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27e307be..dd1e5c6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,17 @@ name: Release on: release: types: [published] + # A published release is immutable, so a build that fails while publishing it + # cannot be retried by re-publishing the event that triggered it — GitHub + # refuses the draft/publish round trip with `immutable cannot be changed when + # release is immutable`. This is the only path to (re)build an image for a tag + # whose release already exists (#964). + workflow_dispatch: + inputs: + tag: + description: 'Relay release tag to build, e.g. nostream@3.7.0' + required: true + type: string jobs: build-and-push-image: @@ -10,7 +21,7 @@ jobs: # nostream@X.Y.Z, website releases tag nostream-website@X.Y.Z. Only a relay # tag should build the relay container image. The v* branch is kept for the # releases cut before the repo moved to changesets tagging. - if: startsWith(github.event.release.tag_name, 'nostream@') || startsWith(github.event.release.tag_name, 'v') + if: github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'nostream@') || startsWith(github.event.release.tag_name, 'v') name: Build and push container image runs-on: ubuntu-latest env: @@ -46,11 +57,18 @@ jobs: id: tags shell: bash env: - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} PRERELEASE: ${{ github.event.release.prerelease }} run: | set -euo pipefail image="$REGISTRY/$IMAGE_NAME" + # A dispatch has no release flags, so a tag that is not a relay release + # is refused here rather than built: `github.event.release.tag_name` is + # empty, and only this check stands between a typo and a pushed image. + case "$RELEASE_TAG" in + nostream@*|v*) ;; + *) echo "::error::not a relay release tag: '$RELEASE_TAG'"; exit 1 ;; + esac # `nostream@3.7.0` -> `3.7.0`; the pre-changesets form `v3.4.1` -> `3.4.1`. version="${RELEASE_TAG##*@}" version="${version#v}" @@ -61,8 +79,16 @@ jobs: v*) tags="$tags,$image:$RELEASE_TAG" ;; esac # `latest` is the tag both deploy stacks pull, so a prerelease must not - # move it onto a test build. - if [ "$PRERELEASE" != "true" ]; then + # move it onto a test build. A release event states this directly; a + # dispatch does not, so a pre-release version is read from the tag. + prerelease="${PRERELEASE:-}" + if [ -z "$prerelease" ]; then + case "$version" in + *-*) prerelease=true ;; + *) prerelease=false ;; + esac + fi + if [ "$prerelease" != "true" ]; then tags="$tags,$image:latest" fi for tag in ${tags//,/ }; do diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index 3b1a9095..bb8459b6 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -25,26 +25,30 @@ type Workflow = { // and a relay image built from the wrong commit, so it is exercised rather than // string-matched: a typo that still contains the right substring would pass a // `to.include` and skip every relay release. -const evaluateReleaseGate = (tagName: string): boolean => { +const evaluateReleaseGate = (tagName: string, isDispatch = false): boolean => { const expression = (load( readFileSync(join(process.cwd(), '.github', 'workflows', 'release.yml'), 'utf-8'), ) as Workflow).jobs['build-and-push-image'].if ?? '' - // Only the `startsWith(github.event.release.tag_name, '…')` clauses are used - // in this expression; anything else is a workflow change that needs review. + // Only two clause forms are allowed in this expression; anything else is a + // workflow change that needs review rather than a silent pass. const clauses = String(expression) .split('||') .map((clause) => clause.trim()) const startsWith = (value: string, prefix: string) => value.startsWith(prefix) - // Translate the two GitHub expression forms into JavaScript. - const js = clauses - .map((clause) => - clause.replace(/^startsWith\(github\.event\.release\.tag_name, '([^']*)'\)$/, - (_, prefix: string) => `startsWith(tagName, ${JSON.stringify(prefix)})`), - ) - .join(' || ') + const toJs = (clause: string): string => { + const prefix = clause.match(/^startsWith\(github\.event\.release\.tag_name, '([^']*)'\)$/) + if (prefix) return `startsWith(tagName, ${JSON.stringify(prefix[1])})` + const event = clause.match(/^github\.event_name == '([^']*)'$/) + if (event) return `isDispatch === ${event[1] === 'workflow_dispatch'}` + throw new Error(`unsupported clause in the release gate: ${clause}`) + } // eslint-disable-next-line no-new-func - return new Function('tagName', 'startsWith', `return ${js}`)(tagName, startsWith) as boolean + return new Function('tagName', 'startsWith', 'isDispatch', `return ${clauses.map(toJs).join(' || ')}`)( + tagName, + startsWith, + isDispatch, + ) as boolean } // The tags are computed by a `run:` block, not by metadata-action's rules, @@ -59,9 +63,10 @@ const tagStep = (workflow: Workflow): string => { return run } -// Executes the step's own `run:` block with the two inputs it maps from the -// release event, and returns the tags it wrote to the job output. -const imageTags = (workflow: Workflow, releaseTag: string, prerelease: boolean): string[] => { +// Executes the step's own `run:` block with the inputs it maps from the release +// event, and returns the tags it wrote to the job output. `undefined` means a +// `workflow_dispatch`, where the event carries no flags at all. +const imageTags = (workflow: Workflow, releaseTag: string, prerelease?: boolean): string[] => { const dir = mkdtempSync(join(tmpdir(), 'release-tags-')) try { const out = join(dir, 'github-output') @@ -73,7 +78,7 @@ const imageTags = (workflow: Workflow, releaseTag: string, prerelease: boolean): REGISTRY: 'ghcr.io', IMAGE_NAME: 'cameri/nostream', RELEASE_TAG: releaseTag, - PRERELEASE: String(prerelease), + ...(prerelease === undefined ? {} : { PRERELEASE: String(prerelease) }), GITHUB_OUTPUT: out, }, }) @@ -127,6 +132,14 @@ describe('container image publishing workflow', () => { expect(evaluateReleaseGate('nostream-website@1.3.1')).to.equal(false) }) + // A published release is immutable, so the build that publishes its image + // cannot be retried through the same event. The dispatch path is what makes a + // failed build recoverable; the step's own range check keeps it from building + // a non-relay tag, since a dispatch carries no release tag at all. + it('accepts a dispatched run so a failed build can be retried', () => { + expect(evaluateReleaseGate('', true)).to.equal(true) + }) + // The deploy path pulls `:latest`, so the release must actually push it. it('tags a stable release with its version and latest', () => { expect(imageTags(releaseWorkflow, 'nostream@3.7.0', false)).to.deep.equal(['3.7.0', 'latest']) @@ -151,6 +164,25 @@ describe('container image publishing workflow', () => { expect(() => imageTags(releaseWorkflow, 'nostream@3.7.0+build', false)).to.throw(/not a legal Docker tag/) }) + // A published release is immutable, so a failed build has to be re-runnable + // by hand — that is what workflow_dispatch exists for, and it carries no + // release flags for the step to read. + it('builds a dispatched tag with the same tags as the release event', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.7.0')).to.deep.equal(['3.7.0', 'latest']) + }) + + // Without the event's flag the prerelease has to be read from the tag, or a + // dispatch of an rc silently moves `latest` onto a test build. + it('reads a prerelease out of a dispatched tag', () => { + expect(imageTags(releaseWorkflow, 'nostream@3.8.0-rc.1')).to.deep.equal(['3.8.0-rc.1']) + }) + + // `github.event.release.tag_name` is empty on a dispatch, so a mistyped input + // is the only thing that would otherwise reach the build. + it('refuses a dispatched tag that is not a relay release', () => { + expect(() => imageTags(releaseWorkflow, 'nostream-website@1.3.2')).to.throw(/not a relay release tag/) + }) + it('builds with the tags the step computed', () => { const build = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((step) => step.with?.tags) expect(build?.with?.tags, 'the build must consume the computed tags').to.equal('${{ steps.tags.outputs.tags }}') From 40d55028af5561c5c996e703441c29a91699443a Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:28:22 -0400 Subject: [PATCH 2/2] ci(release): check out the tag a dispatch names actions/checkout defaulted to the ref the workflow started on, so dispatching from main with tag=nostream@3.7.0 would have built main's HEAD and published it as the 3.7.0 image. Check out refs/tags/ when an input is present and fall back to the event's own ref, which for a release is already the tag. The test asserts the shape of the expression: there is no GitHub expression engine here to execute it, and the mapping is what the reviewer flagged. --- .github/workflows/release.yml | 6 ++++++ test/unit/ci/publish-container-image.spec.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd1e5c6b..8d43b158 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,12 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v3 + # Build the code the tag names, not the branch the dispatch was started + # from. A release event already carries the tag as GITHUB_REF, so the + # input only overrides it; checking out `main` while pushing the image as + # `3.7.0` would publish the wrong source under a release version. + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} - name: Log in to the Container registry uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 with: diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index bb8459b6..71289c6b 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -183,6 +183,17 @@ describe('container image publishing workflow', () => { expect(() => imageTags(releaseWorkflow, 'nostream-website@1.3.2')).to.throw(/not a relay release tag/) }) + // The image must be built from the code its tag names. A dispatch starts on a + // branch, so without this the default ref is checked out and that branch's + // HEAD is published as a release version. The expression itself only runs on + // GitHub, so this asserts its shape: the tag form for a dispatch, and the + // event's own ref when no input is present. + it('checks out the release tag rather than the dispatching branch', () => { + const step = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((entry) => entry.with?.ref) + const expression = (step?.with?.ref ?? '').replace(/^\$\{\{\s*/, '').replace(/\s*\}\}$/, '') + expect(expression, 'a dispatch must build the tag it was given').to.include("format('refs/tags/{0}', inputs.tag)") + expect(expression, 'a release event already carries its tag in github.ref').to.include('|| github.ref') + }) it('builds with the tags the step computed', () => { const build = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((step) => step.with?.tags) expect(build?.with?.tags, 'the build must consume the computed tags').to.equal('${{ steps.tags.outputs.tags }}')