Skip to content
Merged
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
142 changes: 142 additions & 0 deletions .github/workflows/delete-retired-sms-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
# TEMPORARY owner-approved cleanup workflow. Deletes the retired OCI chart
# package ghcr.io/makeitworkcloud/charts/opencode-sms-bridge (chart source
# removed in b7f85df8). Manual workflow_dispatch only, main only, exact
# repo only. Remove this workflow after the confirmed cleanup run.
#
# Known limits, deliberately not bypassed:
# - GitHub refuses REST deletion of a public package when any version has
# more than 5000 downloads; the run fails and GitHub support is the path.
# - GITHUB_TOKEN deletion works because this repository's publishing
# workflow created the package (publisher repo holds admin by default).
# - A deleted package stays restorable for 30 days via the packages API.
name: delete-retired-sms-package

on:
workflow_dispatch:

permissions:
contents: read
packages: write

concurrency:
group: ${{ github.repository }}-delete-retired-sms-package
cancel-in-progress: false

jobs:
delete:
if: github.repository == 'makeitworkcloud/charts' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify and delete package charts/opencode-sms-bridge
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = 'makeitworkcloud';
const expectedRepo = 'makeitworkcloud/charts';
const expectedName = 'charts/opencode-sms-bridge';
const packageType = 'container';

// Fail-closed guard mirroring the job-level condition.
const repoFull = `${context.repo.owner}/${context.repo.repo}`;
if (repoFull !== expectedRepo || context.ref !== 'refs/heads/main') {
core.setFailed(`refusing: ${repoFull}@${context.ref} is not ${expectedRepo}@refs/heads/main`);
return;
}

// 0) Pre-request self-check BEFORE any API call: structured REST
// methods encode route parameters themselves (octokit
// endpoint.js url-template applies encodeURIComponent to
// simple {name} placeholders), so the raw name must build a
// URL with the slashed name encoded exactly once.
const route = github.request.endpoint(
'GET /orgs/{org}/packages/{package_type}/{package_name}',
{ org: owner, package_type: packageType, package_name: expectedName },
);
const expectedPath = `/orgs/${owner}/packages/${packageType}/${encodeURIComponent(expectedName)}`;
const builtPath = new URL(route.url).pathname;
if (builtPath !== expectedPath) {
core.setFailed(`route encoding check failed: ${builtPath} != ${expectedPath}`);
return;
}

// 1) Paginated org inventory GET. A 403 fails the step; only a
// successful inventory may conclude the package is absent.
const inventory = await github.paginate(
github.rest.packages.listPackagesForOrganization,
{ org: owner, package_type: packageType, per_page: 100 },
);
core.info(`org ${packageType} inventory: ${inventory.length} package(s)`);
const listed = inventory.find(
(p) => p.name === expectedName && p.package_type === packageType,
);
if (!listed) {
// Idempotent re-run: absent after a successful inventory GET.
core.notice(`package "${expectedName}" is absent from the org inventory; nothing to do`);
return;
}

// 2) Authoritative metadata. Verify before any DELETE; fail
// closed on any mismatch or missing repository association.
const meta = (
await github.rest.packages.getPackageForOrganization({
org: owner,
package_type: packageType,
package_name: expectedName,
})
).data;
const repoLabel = meta.repository ? meta.repository.full_name : 'none';
core.info(`resolved id=${meta.id} name=${meta.name} type=${meta.package_type}`);
core.info(`visibility=${meta.visibility} version_count=${meta.version_count} repository=${repoLabel}`);
const problems = [];
if (meta.id !== listed.id) problems.push(`inventory id ${listed.id} != metadata id ${meta.id}`);
if (meta.name !== expectedName) problems.push(`name "${meta.name}" != "${expectedName}"`);
if (meta.package_type !== packageType) problems.push(`type "${meta.package_type}" != "${packageType}"`);
if (!meta.repository) problems.push('package has no repository association');
else if (meta.repository.full_name !== expectedRepo) {
problems.push(`repository association ${meta.repository.full_name} != ${expectedRepo}`);
}
if (problems.length > 0) {
core.setFailed(`verification failed, refusing to delete: ${problems.join('; ')}`);
return;
}

// 3) Count real versions from the API before deleting.
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{ org: owner, package_type: packageType, package_name: expectedName, per_page: 100 },
);
core.info(`versions present: ${versions.length} (sample id=${versions.length > 0 ? versions[0].id : 'none'})`);

// 4) Delete the ENTIRE package, not individual versions.
await github.rest.packages.deletePackageForOrganization({
org: owner,
package_type: packageType,
package_name: expectedName,
});
core.info(`DELETE accepted for "${expectedName}" (id=${meta.id})`);

// 5) Verify with the same token: GET must 404 and the relisted
// inventory must not contain the package.
try {
await github.rest.packages.getPackageForOrganization({
org: owner,
package_type: packageType,
package_name: expectedName,
});
core.setFailed('package is still readable after deletion');
return;
} catch (postError) {
if (postError.status !== 404) throw postError;
core.info('post-delete GET returned 404 as expected');
}
const relisted = await github.paginate(
github.rest.packages.listPackagesForOrganization,
{ org: owner, package_type: packageType, per_page: 100 },
);
if (relisted.some((p) => p.name === expectedName)) {
core.setFailed('package is still listed in the org inventory after deletion');
return;
}
core.notice(`deleted package "${expectedName}" (id=${meta.id}, ${versions.length} version(s)); relist confirms absence`);
Loading