From 57196fba51d12edc2e8239e244e530c9aa40236c Mon Sep 17 00:00:00 2001 From: Steven Welch Date: Sat, 19 Sep 2026 23:40:40 -0600 Subject: [PATCH] ci: drop org inventory and verify with exact package endpoints Both dispatched runs failed at the first org-wide inventory GET with 400 Invalid argument before any deletion; the repository GITHUB_TOKEN is not authorized for org-wide package enumeration. Remove the inventory dependency entirely: the exact package-scoped endpoints remain the documented GITHUB_TOKEN path (the container registry grants the publishing repository's workflow token admin, which reads and writes package metadata and deletes the package). The script now starts with the exact getPackageForOrganization call using the raw package name. Any error there, including 404, fails the run: 404 may mask a permission problem, so absence is never inferred. The id must be a positive safe integer, and name, package_type, and the exact repository association are verified fail-closed, with a missing association failing. Versions come from the exact versions endpoint; the whole package is deleted; and verification is bounded to at most five metadata GETs with a 1s wait on 200 (eventual consistency), 404 confirming the accepted 204 deletion. No relist is performed. 400 or 403 on any call fails without expanded access or PAT fallback. All API errors are reported through core.setFailed with status and message only; raw error objects, stack traces, and request headers are never logged. Guards are unchanged: manual workflow_dispatch with zero inputs, exact repository and refs/heads/main enforcement at the job and script level, five-minute timeout, fixed per-repo concurrency with cancel-in-progress false, the once-encoding pre-request URL check, and the pinned actions/github-script v9.0.0 commit. No dispatch, merge, or live API execution happens in this commit. --- .../workflows/delete-retired-sms-package.yml | 133 +++++++++++------- 1 file changed, 81 insertions(+), 52 deletions(-) diff --git a/.github/workflows/delete-retired-sms-package.yml b/.github/workflows/delete-retired-sms-package.yml index 38b438c..18c237f 100644 --- a/.github/workflows/delete-retired-sms-package.yml +++ b/.github/workflows/delete-retired-sms-package.yml @@ -5,6 +5,10 @@ # Remove this workflow after the confirmed cleanup run. # # Known limits, deliberately not bypassed: +# - The org-wide package inventory GET returned 400 Invalid argument for the +# repository GITHUB_TOKEN, so only the exact package-scoped endpoints are +# used; an initial metadata 404 FAILS the run (it may mask a permission +# problem) instead of claiming the package is absent. # - 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 @@ -37,6 +41,15 @@ jobs: const expectedRepo = 'makeitworkcloud/images'; const expectedName = 'opencode-sms-bridge'; const packageType = 'container'; + const maxVerifyAttempts = 5; + + // Fail with status and message only: never log raw error + // objects, stack traces, or request headers. + const failWith = (label, error) => { + const status = error && Number.isInteger(error.status) ? error.status : 'no-status'; + const message = error && error.message ? String(error.message) : 'unknown error'; + core.setFailed(`${label}: status=${status} message=${message}`); + }; // Fail-closed guard mirroring the job-level condition. const repoFull = `${context.repo.owner}/${context.repo.repo}`; @@ -61,36 +74,31 @@ jobs: 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`); + // 1) Exact metadata GET with the publisher GITHUB_TOKEN. The + // org-wide inventory endpoint is NOT used (it returns 400 + // Invalid argument for this token). Any error, including + // 404, FAILS: 404 may mask a permission problem, so absence + // is never inferred here. + let meta; + try { + meta = ( + await github.rest.packages.getPackageForOrganization({ + org: owner, + package_type: packageType, + package_name: expectedName, + }) + ).data; + } catch (error) { + failWith('package metadata GET failed', error); 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 (!Number.isSafeInteger(meta.id) || meta.id <= 0) { + problems.push(`id ${meta.id} is not a positive safe integer`); + } 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'); @@ -102,41 +110,62 @@ jobs: 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 }, - ); + // 2) Count real versions from the exact endpoint before deleting. + let versions; + try { + versions = await github.paginate( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, + { org: owner, package_type: packageType, package_name: expectedName, per_page: 100 }, + ); + } catch (error) { + failWith('package versions GET failed', error); + return; + } 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. + // 3) Delete the ENTIRE package, not individual versions. A + // successful 204 confirms the deletion was accepted. try { - await github.rest.packages.getPackageForOrganization({ + await github.rest.packages.deletePackageForOrganization({ org: owner, package_type: packageType, package_name: expectedName, }); - core.setFailed('package is still readable after deletion'); + } catch (error) { + failWith('package DELETE failed', error); 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.info(`DELETE accepted (204) for "${expectedName}" (id=${meta.id})`); + + // 4) Bounded verification with the same token: at most five + // metadata GETs; 200 waits 1s and retries (eventual + // consistency), 404 confirms the accepted deletion, any + // other result fails. No org-wide relist is used (the + // inventory endpoint 400s for this token). + for (let attempt = 1; attempt <= maxVerifyAttempts; attempt += 1) { + try { + await github.rest.packages.getPackageForOrganization({ + org: owner, + package_type: packageType, + package_name: expectedName, + }); + } catch (error) { + if (error && error.status === 404) { + core.notice( + `deleted package "${expectedName}" (id=${meta.id}, ${versions.length} version(s)); ` + + `post-delete GET returned 404 on attempt ${attempt}`, + ); + return; + } + failWith(`post-delete GET failed on attempt ${attempt}`, error); + return; + } + if (attempt < maxVerifyAttempts) { + core.info(`post-delete GET attempt ${attempt} still returned 200; waiting 1s for eventual consistency`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } } - core.notice(`deleted package "${expectedName}" (id=${meta.id}, ${versions.length} version(s)); relist confirms absence`); + core.setFailed( + `package "${expectedName}" (id=${meta.id}) was still readable after DELETE (204) ` + + `and ${maxVerifyAttempts} verification attempts`, + );