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
29 changes: 6 additions & 23 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
73 changes: 55 additions & 18 deletions .github/workflows/pull_request_cleanup_tags_and_releases.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,64 @@
name: 🧼 Pull Request - Cleanup Tags and Releases
name: Clean up a controlled test release

on:
delete:
pull_request:
types: [closed]
workflow_call:
inputs:
tag:
required: true
type: string
expected_sha:
required: true
type: string
workflow_dispatch:
inputs:
regex:
description: 'Regex to search by'
required: false
pr_number:
description: 'PR Number to search by'
required: false
tag:
Comment thread
Jamie-BitFlight marked this conversation as resolved.
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 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.');
}
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}.`);
91 changes: 91 additions & 0 deletions .github/workflows/test-release-publication.yml
Original file line number Diff line number Diff line change
@@ -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.`);
Loading