name: ZyraSec approved repair
run-name: ZyraSec fix ${{ inputs.job_id }}
on:
  workflow_dispatch:
    inputs:
      job_id:
        description: Approved ZyraSec job ID
        required: true
      spec:
        description: Immutable typed action JSON
        required: true
      spec_hash:
        description: SHA-256 approved in the dashboard
        required: true
permissions:
  contents: read
concurrency:
  group: zyrasec-${{ inputs.job_id }}
  cancel-in-progress: false
jobs:
  prepare:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
        with:
          persist-credentials: false
          fetch-depth: 0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
        with:
          node-version: '24'
      - name: Validate approval and prepare package metadata
        env:
          APPROVED_SPEC: ${{ inputs.spec }}
          APPROVED_HASH: ${{ inputs.spec_hash }}
          JOB_ID: ${{ inputs.job_id }}
        run: |
          node --input-type=module <<'JS'
          import fs from 'node:fs';
          import path from 'node:path';
          import {createHash} from 'node:crypto';
          import {execFileSync} from 'node:child_process';
          const raw=process.env.APPROVED_SPEC,s=JSON.parse(raw),hash=createHash('sha256').update(raw).digest('hex');
          if(hash!==process.env.APPROVED_HASH||s.action!=='npm-update'||s.repository!==process.env.GITHUB_REPOSITORY||!/^[a-f0-9-]{36}$/.test(process.env.JOB_ID)||!/^[a-f0-9]{40}$/.test(s.baseSha)||!/^(@[A-Za-z0-9_.-]+\/)?[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(s.package)||!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(s.targetVersion)||!/^(?:[A-Za-z0-9_.-]+\/)*package-lock\.json$/.test(s.lockfile)||s.lockfile.split('/').includes('..'))throw Error('Invalid approved action');
          execFileSync('git',['checkout','--detach',s.baseSha]);
          const dir=path.dirname(s.lockfile),manifestPath=path.join(dir,'package.json'),manifest=JSON.parse(fs.readFileSync(manifestPath));
          const section=['dependencies','devDependencies','optionalDependencies'].find(k=>Object.hasOwn(manifest[k]||{},s.package));
          if(section)manifest[section][s.package]=s.targetVersion;
          else {if(manifest.overrides&&typeof manifest.overrides!=='object')throw Error('Review existing overrides manually');manifest.overrides={...(manifest.overrides||{}),[s.package]:s.targetVersion};}
          fs.writeFileSync(manifestPath,JSON.stringify(manifest,null,2)+'\n');
          const env={...process.env};delete env.GITHUB_TOKEN;delete env.GH_TOKEN;
          execFileSync('npm',['install','--package-lock-only','--ignore-scripts','--no-audit','--no-fund'],{cwd:dir,env,stdio:'inherit',timeout:300000});
          const lock=JSON.parse(fs.readFileSync(s.lockfile));
          if(!Object.entries(lock.packages||{}).some(([key,p])=>p.version===s.targetVersion&&(p.name===s.package||key==='node_modules/'+s.package||key.endsWith('/node_modules/'+s.package))))throw Error('Target version absent from updated lockfile');
          const files={[manifestPath]:fs.readFileSync(manifestPath,'base64'),[s.lockfile]:fs.readFileSync(s.lockfile,'base64')};
          fs.mkdirSync('.zyrasec-result',{recursive:true});fs.writeFileSync('.zyrasec-result/update.json',JSON.stringify({specHash:hash,files}));
          JS
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
        with:
          name: zyrasec-update
          path: .zyrasec-result/update.json
          retention-days: 1
  test:
    needs: prepare
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
        with:
          persist-credentials: false
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
        with:
          node-version: '24'
      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
        with:
          name: zyrasec-update
          path: .zyrasec-result
      - name: Test the proposed dependency change with read-only repository permissions
        env:
          APPROVED_SPEC: ${{ inputs.spec }}
          APPROVED_HASH: ${{ inputs.spec_hash }}
        run: |
          node --input-type=module <<'JS'
          import fs from 'node:fs';import path from 'node:path';import {execFileSync} from 'node:child_process';import {createHash} from 'node:crypto';
          const raw=process.env.APPROVED_SPEC,s=JSON.parse(raw);if(createHash('sha256').update(raw).digest('hex')!==process.env.APPROVED_HASH)throw Error('Approval mismatch');execFileSync('git',['checkout','--detach',s.baseSha]);
          const update=JSON.parse(fs.readFileSync('.zyrasec-result/update.json'));if(update.specHash!==process.env.APPROVED_HASH)throw Error('Artifact mismatch');const allowed=[s.lockfile,path.join(path.dirname(s.lockfile),'package.json')];
          for(const [file,value] of Object.entries(update.files)){if(!allowed.includes(file))throw Error('Unexpected file');fs.writeFileSync(file,Buffer.from(value,'base64'));}
          const env={...process.env};delete env.GITHUB_TOKEN;delete env.GH_TOKEN;
          execFileSync('npm',['ci','--ignore-scripts','--no-audit','--no-fund'],{cwd:path.dirname(s.lockfile),env,stdio:'inherit',timeout:300000});
          const manifest=JSON.parse(fs.readFileSync(allowed[1]));if(!manifest.scripts?.test)throw Error('Define a project test script before enabling automated dependency repairs');
          execFileSync('npm',['test'],{cwd:path.dirname(s.lockfile),env,stdio:'inherit',timeout:600000});
          JS
  publish:
    needs: [prepare, test]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
        with:
          name: zyrasec-update
          path: result
      - name: Open a pull request after tests pass
        env:
          GH_TOKEN: ${{ github.token }}
          APPROVED_SPEC: ${{ inputs.spec }}
          APPROVED_HASH: ${{ inputs.spec_hash }}
          JOB_ID: ${{ inputs.job_id }}
        run: |
          node --input-type=module <<'JS'
          import fs from 'node:fs';import path from 'node:path';import {createHash} from 'node:crypto';
          const raw=process.env.APPROVED_SPEC,s=JSON.parse(raw),job=process.env.JOB_ID;if(createHash('sha256').update(raw).digest('hex')!==process.env.APPROVED_HASH||s.repository!==process.env.GITHUB_REPOSITORY||!/^[a-f0-9-]{36}$/.test(job))throw Error('Approval mismatch');
          const api=async(p,method='GET',data)=>{const r=await fetch('https://api.github.com/repos/'+s.repository+p,{method,headers:{authorization:'Bearer '+process.env.GH_TOKEN,accept:'application/vnd.github+json','content-type':'application/json'},...(data?{body:JSON.stringify(data)}:{})});if(!r.ok)throw Error('GitHub API '+r.status);return r.json();};
          const metadata=await api(''),ref=await api('/git/ref/heads/'+encodeURIComponent(metadata.default_branch));if(ref.object.sha!==s.baseSha)throw Error('Base commit changed; create a fresh reviewed action');
          const update=JSON.parse(fs.readFileSync('result/update.json'));if(update.specHash!==process.env.APPROVED_HASH)throw Error('Artifact mismatch');const allowed=[s.lockfile,path.join(path.dirname(s.lockfile),'package.json')];
          const base=await api('/git/commits/'+s.baseSha),tree=[];for(const [file,content] of Object.entries(update.files)){if(!allowed.includes(file))throw Error('Unexpected file');const blob=await api('/git/blobs','POST',{content,encoding:'base64'});tree.push({path:file,mode:'100644',type:'blob',sha:blob.sha});}
          const created=await api('/git/trees','POST',{base_tree:base.tree.sha,tree}),commit=await api('/git/commits','POST',{message:'Security: update '+s.package+' to '+s.targetVersion,tree:created.sha,parents:[s.baseSha]}),branch='zyrasec/fix-'+job;
          await api('/git/refs','POST',{ref:'refs/heads/'+branch,sha:commit.sha});const pr=await api('/pulls','POST',{title:'Security: update '+s.package+' to '+s.targetVersion,head:branch,base:metadata.default_branch,body:'Prepared from an exact ZyraSec-approved action. Dependency install scripts were disabled during preparation. The project test script passed in a separate read-only job. Review compatibility, runtime behavior and deployment before merging. Auto-merge is not enabled.\n\nApproval SHA-256: '+process.env.APPROVED_HASH});console.log('Pull request opened: '+pr.html_url);
          JS
