From cd081cb0ef53e260172f6f67552a50c2609cfece Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:25:57 +1100 Subject: [PATCH 1/5] fix(ci): restrict cleanup to identified test releases --- ...pull_request_cleanup_tags_and_releases.yml | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/.github/workflows/pull_request_cleanup_tags_and_releases.yml b/.github/workflows/pull_request_cleanup_tags_and_releases.yml index 98d1ad5..adb6cc2 100644 --- a/.github/workflows/pull_request_cleanup_tags_and_releases.yml +++ b/.github/workflows/pull_request_cleanup_tags_and_releases.yml @@ -1,27 +1,49 @@ -name: 🧼 Pull Request - Cleanup Tags and Releases +name: Clean up a controlled test release on: - delete: - pull_request: - types: [closed] workflow_dispatch: inputs: - regex: - description: 'Regex to search by' - required: false - pr_number: - description: 'PR Number to search by' - required: false + tag: + description: 'Exact test tag: vMAJOR.MINOR.PATCH-repair.RUN.ATTEMPT' + required: true + type: string + expected_sha: + description: 'Full commit SHA recorded when the test release was created' + required: true + type: string + +permissions: + contents: write + +concurrency: + group: cleanup-test-release-${{ inputs.tag }} + cancel-in-progress: false jobs: - test: - name: Cleanup Tags and Releases - runs-on: ubuntu-latest + cleanup: + runs-on: ubuntu-24.04 steps: - - name: Remove unused releases and tags - uses: Broadshield/github-action-cleanup-releases-and-tags@main + - name: Validate ownership and delete only the named test release and tag + uses: actions/github-script@v8 env: - GITHUB_TOKEN: ${{ github.token }} + TEST_TAG: ${{ inputs.tag }} + EXPECTED_SHA: ${{ inputs.expected_sha }} with: - regex: ${{ github.event.inputs.regex }} - pr_number: ${{ github.event.inputs.pr_number }} + script: | + const tag = process.env.TEST_TAG; + const expected = process.env.EXPECTED_SHA; + if (!/^v[0-9]+\.[0-9]+\.[0-9]+-repair\.[0-9]+\.[0-9]+$/.test(tag)) { + throw new Error('Only explicitly named repair test tags can be removed.'); + } + if (!/^[a-f0-9]{40}$/.test(expected)) { + throw new Error('The expected full commit SHA is required.'); + } + const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); + if (commit.sha !== expected) throw new Error('Test tag does not target the expected commit.'); + const { data: release } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }); + if (release.tag_name !== tag || !release.prerelease) { + throw new Error('The named release must be a prerelease.'); + } + await github.rest.repos.deleteRelease({ ...context.repo, release_id: release.id }); + await github.rest.git.deleteRef({ ...context.repo, ref: `tags/${tag}` }); + core.info(`Deleted controlled test release ${tag} at ${expected}.`); From 638be8aa5f05a0345e683624d2e919b8e4472203 Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:27:42 +1100 Subject: [PATCH 2/5] test(release): publish and clean up a uniquely owned prerelease --- ...pull_request_cleanup_tags_and_releases.yml | 27 ++++-- .../workflows/test-release-publication.yml | 91 +++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test-release-publication.yml diff --git a/.github/workflows/pull_request_cleanup_tags_and_releases.yml b/.github/workflows/pull_request_cleanup_tags_and_releases.yml index adb6cc2..98855f6 100644 --- a/.github/workflows/pull_request_cleanup_tags_and_releases.yml +++ b/.github/workflows/pull_request_cleanup_tags_and_releases.yml @@ -1,6 +1,14 @@ name: Clean up a controlled test release on: + workflow_call: + inputs: + tag: + required: true + type: string + expected_sha: + required: true + type: string workflow_dispatch: inputs: tag: @@ -38,12 +46,19 @@ jobs: if (!/^[a-f0-9]{40}$/.test(expected)) { throw new Error('The expected full commit SHA is required.'); } - const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); - if (commit.sha !== expected) throw new Error('Test tag does not target the expected commit.'); - const { data: release } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }); - if (release.tag_name !== tag || !release.prerelease) { + const absentOn404 = async (request) => { + try { return (await request()).data; } + catch (error) { if (error.status === 404) return null; throw error; } + }; + const commit = await absentOn404(() => github.rest.repos.getCommit({ ...context.repo, ref: tag })); + const release = await absentOn404(() => github.rest.repos.getReleaseByTag({ ...context.repo, tag })); + if (commit && commit.sha !== expected) throw new Error('Test tag does not target the expected commit.'); + if (release && (release.tag_name !== tag || !release.prerelease)) { throw new Error('The named release must be a prerelease.'); } - await github.rest.repos.deleteRelease({ ...context.repo, release_id: release.id }); - await github.rest.git.deleteRef({ ...context.repo, ref: `tags/${tag}` }); + if (release && !commit && release.target_commitish !== expected) { + throw new Error('Cannot verify the target of a release whose tag is missing.'); + } + if (release) await github.rest.repos.deleteRelease({ ...context.repo, release_id: release.id }); + if (commit) await github.rest.git.deleteRef({ ...context.repo, ref: `tags/${tag}` }); core.info(`Deleted controlled test release ${tag} at ${expected}.`); diff --git a/.github/workflows/test-release-publication.yml b/.github/workflows/test-release-publication.yml new file mode 100644 index 0000000..c653188 --- /dev/null +++ b/.github/workflows/test-release-publication.yml @@ -0,0 +1,91 @@ +name: Controlled release publication test +on: + pull_request: + paths: + - '.github/workflows/pull_request_cleanup_tags_and_releases.yml' + - '.github/workflows/test-release-publication.yml' + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.publish.outputs.tag }} + sha: ${{ steps.publish.outputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Package the exact checked-out action + run: git archive --format=tar.gz --output=action.tar.gz HEAD action.yml dist package.json README.md LICENSE + - name: Publish a unique prerelease without moving any existing ref + id: publish + uses: actions/github-script@v8 + with: + script: | + const fs = require('node:fs'); + const { execFileSync } = require('node:child_process'); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const version = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; + const tag = `v${version}-repair.${context.runId}.${process.env.GITHUB_RUN_ATTEMPT}`; + // createRef rejects an existing tag; this workflow never updates refs. + await github.rest.git.createRef({ ...context.repo, ref: `refs/tags/${tag}`, sha }); + core.setOutput('tag', tag); + core.setOutput('sha', sha); + const { data: release } = await github.rest.repos.createRelease({ + ...context.repo, tag_name: tag, target_commitish: sha, + name: `Controlled publication test ${tag}`, prerelease: true, make_latest: 'false', + body: `Disposable publication verification for commit ${sha}.`, + }); + await github.rest.repos.uploadReleaseAsset({ + ...context.repo, release_id: release.id, name: 'action.tar.gz', + data: fs.readFileSync('action.tar.gz'), + }); + const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); + const { data: published } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }); + if (commit.sha !== sha || published.draft || !published.prerelease || + published.tag_name !== tag || !published.body.includes(sha) || + published.assets.length !== 1 || published.assets[0].name !== 'action.tar.gz') { + throw new Error('Published test release differs from the requested artifact and revision.'); + } + - name: Verify the published artifact byte for byte + env: + GH_TOKEN: ${{ github.token }} + TEST_TAG: ${{ steps.publish.outputs.tag }} + run: | + gh release download "$TEST_TAG" --repo "$GITHUB_REPOSITORY" --pattern action.tar.gz --dir downloaded + cmp action.tar.gz downloaded/action.tar.gz + + cleanup: + if: always() && needs.publish.outputs.tag != '' + needs: publish + uses: ./.github/workflows/pull_request_cleanup_tags_and_releases.yml + with: + tag: ${{ needs.publish.outputs.tag }} + expected_sha: ${{ needs.publish.outputs.sha }} + + verify-cleanup: + needs: [publish, cleanup] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/github-script@v8 + env: + TEST_TAG: ${{ needs.publish.outputs.tag }} + with: + script: | + const tag = process.env.TEST_TAG; + for (const lookup of [ + () => github.rest.repos.getReleaseByTag({ ...context.repo, tag }), + () => github.rest.git.getRef({ ...context.repo, ref: `tags/${tag}` }), + ]) { + try { await lookup(); } + catch (error) { if (error.status === 404) continue; throw error; } + throw new Error(`Controlled test artifact ${tag} remains after cleanup.`); + } + core.info(`Verified exact test release and tag ${tag} are absent.`); From df13c52b8935ca783ebdaf2c6f8eb10a2887ac77 Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:30:42 +1100 Subject: [PATCH 3/5] docs(ci): explain exact test release cleanup and recovery --- .github/workflows/README.md | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b1fbf73..ddef541 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -8,7 +8,8 @@ This directory contains GitHub Actions workflow definitions for CI/CD automation | -------------------------------------------- | -------------------------- | ------------------------------------ | | `create_tag.yml` | Push to `main` | Version bump and tag creation | | `publish_action.yml` | Tag push | Publish action to GitHub Marketplace | -| `pull_request_cleanup_tags_and_releases.yml` | PR closed / branch deleted | Clean up test tags and releases | +| `pull_request_cleanup_tags_and_releases.yml` | Manual / reusable call | Delete one verified test release and tag | +| `test-release-publication.yml` | Workflow-changing PR / manual | Verify isolated publication and exact cleanup | | `push_code_linting.yml` | Pull request | ESLint code review | ## Workflow Details @@ -58,31 +59,13 @@ This directory contains GitHub Actions workflow definitions for CI/CD automation ### pull_request_cleanup_tags_and_releases.yml -**Name**: Pull Request - Cleanup Tags and Releases +Cleanup is explicitly dispatched or called by the controlled publication test. Supply the exact `tag` in the form `vMAJOR.MINOR.PATCH-repair.RUN.ATTEMPT` and the full `expected_sha` recorded by the publisher. The workflow checks the tag target and prerelease status before deleting that release and tag. It preserves all production releases and workflow history. -**Triggers**: +If a deletion fails partway through, rerun with the same tag and SHA. Missing artifacts are treated as already removed; mismatched targets, stable releases and API permission failures stop cleanup. -- Branch deletion (`delete` event) -- Pull request closed -- Manual dispatch with optional inputs +### test-release-publication.yml -**Purpose**: Removes test/preview tags and releases created during PR development. - -**Inputs** (manual dispatch): -| Input | Required | Description | -|-------|----------|-------------| -| `regex` | No | Regex pattern to match tags for deletion | -| `pr_number` | No | PR number to find associated tags | - -**Steps**: - -1. Uses `Broadshield/github-action-cleanup-releases-and-tags@main` -2. Removes tags/releases matching the pattern or PR number - -**Environment**: - -- Runner: `ubuntu-latest` -- Token: `github.token` (automatic) +Changes to the cleanup/publication-test workflows run a controlled publication test on same-repository PRs. It can also be dispatched manually. The test creates a unique prerelease, attaches the checked-out packaged action, downloads and compares the artifact byte for byte, calls the cleanup workflow, and verifies that the exact test release and tag are absent. Each attempt uses a new run-specific tag and never updates an existing ref. ### push_code_linting.yml From 56fbf046184b219c87ecce1cbaa1e76bfc80d05f Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:36:47 +1100 Subject: [PATCH 4/5] fix(ci): account for read-only Dependabot publication tokens --- .github/workflows/test-release-publication.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-release-publication.yml b/.github/workflows/test-release-publication.yml index c653188..6d0702e 100644 --- a/.github/workflows/test-release-publication.yml +++ b/.github/workflows/test-release-publication.yml @@ -11,7 +11,12 @@ permissions: jobs: publish: - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + # Dependabot PR tokens are read-only; use manual dispatch for those changes. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' && + github.actor != 'dependabot[bot]') runs-on: ubuntu-24.04 outputs: tag: ${{ steps.publish.outputs.tag }} From 1530877d12ed052a96f91bf68b3fd08f4c7526a4 Mon Sep 17 00:00:00 2001 From: Jamie Nelson Date: Sun, 11 Oct 2026 11:38:13 +1100 Subject: [PATCH 5/5] fix(ci): retain publication coverage with configured write permissions --- .github/workflows/test-release-publication.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/test-release-publication.yml b/.github/workflows/test-release-publication.yml index 6d0702e..c653188 100644 --- a/.github/workflows/test-release-publication.yml +++ b/.github/workflows/test-release-publication.yml @@ -11,12 +11,7 @@ permissions: jobs: publish: - # Dependabot PR tokens are read-only; use manual dispatch for those changes. - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login != 'dependabot[bot]' && - github.actor != 'dependabot[bot]') + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-24.04 outputs: tag: ${{ steps.publish.outputs.tag }}