name: docker run-name: ${{ inputs.run-name }} on: workflow_dispatch: inputs: run-name: description: 'set run-name for workflow (multiple calls)' type: string required: false default: 'docker' runs-on: description: 'set runs-on for workflow (github or selfhosted)' type: string required: false default: 'ubuntu-22.04' build: description: 'set WORKFLOW_BUILD' required: false default: 'true' release: description: 'set WORKFLOW_GITHUB_RELEASE' required: false default: 'false' readme: description: 'set WORKFLOW_GITHUB_README' required: false default: 'false' etc: description: 'base64 encoded json string' required: false jobs: docker: runs-on: ${{ inputs.runs-on }} timeout-minutes: 1440 services: registry: image: registry:2 ports: - 5000:5000 permissions: actions: read contents: write packages: write steps: - name: init / checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: ref: ${{ github.ref_name }} fetch-depth: 0 - name: init / setup environment uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 with: script: | const { existsSync, readFileSync } = require('node:fs'); const { resolve } = require('node:path'); const { inspect } = require('node:util'); const { Buffer } = require('node:buffer'); const inputs = `${{ toJSON(github.event.inputs) }}`; const opt = {input:{}, dot:{}}; try{ if(inputs.length > 0){ opt.input = JSON.parse(inputs); if(opt.input?.etc){ opt.input.etc = JSON.parse(Buffer.from(opt.input.etc, 'base64').toString('ascii')); } } }catch(e){ core.warning('could not parse github.event.inputs'); } try{ const path = resolve('.json'); if(existsSync(path)){ try{ opt.dot = JSON.parse(readFileSync(path).toString()); }catch(e){ throw new Error('could not parse .json'); } }else{ throw new Error('.json does not exist'); } }catch(e){ core.setFailed(e); } core.info(inspect(opt, {showHidden:false, depth:null, colors:true})); const docker = { image:{ name:opt.dot.image, arch:(opt.dot.arch || 'linux/amd64,linux/arm64'), prefix:((opt.input?.etc?.semverprefix) ? `${opt.input?.etc?.semverprefix}-` : ''), suffix:((opt.input?.etc?.semversuffix) ? `-${opt.input?.etc?.semversuffix}` : ''), description:(opt.dot?.readme?.description || ''), tags:[], }, app:{ image:opt.dot.image, name:opt.dot.name, version:(opt.input?.etc?.version || opt.dot.semver.version), root:opt.dot.root, UID:(opt.input?.etc?.uid || 1000), GID:(opt.input?.etc?.gid || 1000), no_cache:new Date().getTime(), }, cache:{ registry:'localhost:5000/', }, tags:[], }; docker.cache.name = `${docker.image.name}:${docker.image.prefix}buildcache${docker.image.suffix}`; docker.cache.grype = `${docker.cache.registry}${docker.image.name}:${docker.image.prefix}grype${docker.image.suffix}`; docker.app.prefix = docker.image.prefix; docker.app.suffix = docker.image.suffix; // setup tags if(opt.input?.etc?.dockerfile !== 'arch.dockerfile' && opt.input?.etc?.tag){ docker.image.tags.push(`${context.sha.substring(0,7)}`); docker.image.tags.push(opt.input.etc.tag); docker.image.tags.push(`${opt.input.etc.tag}-${docker.app.version}`); docker.cache.name = `${docker.image.name}:buildcache-${opt.input.etc.tag}`; }else if(opt.dot?.semver?.version){ const semver = opt.dot.semver.version.split('.'); docker.image.tags.push(`${context.sha.substring(0,7)}`); if(Array.isArray(semver)){ if(semver.length >= 1) docker.image.tags.push(`${semver[0]}`); if(semver.length >= 2) docker.image.tags.push(`${semver[0]}.${semver[1]}`); if(semver.length >= 3) docker.image.tags.push(`${semver[0]}.${semver[1]}.${semver[2]}`); } if(opt.dot.semver?.stable && new RegExp(opt.dot.semver.stable, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('stable'); if(opt.dot.semver?.latest && new RegExp(opt.dot.semver.latest, 'ig').test(docker.image.tags.join(','))) docker.image.tags.push('latest'); }else if(opt.input?.etc?.version && opt.input.etc.version === 'latest'){ docker.image.tags.push('latest'); } for(const tag of docker.image.tags){ docker.tags.push(`${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`); docker.tags.push(`ghcr.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`); docker.tags.push(`quay.io/${docker.image.name}:${docker.image.prefix}${tag}${docker.image.suffix}`); } // setup build arguments if(opt.input?.etc?.build?.args){ for(const arg in opt.input.etc.build.args){ docker.app[arg] = opt.input.etc.build.args[arg]; } } if(opt.dot?.build?.args){ for(const arg in opt.dot.build.args){ docker.app[arg] = opt.dot.build.args[arg]; } } const arguments = []; for(const argument in docker.app){ arguments.push(`APP_${argument.toUpperCase()}=${docker.app[argument]}`); } // export to environment core.exportVariable('DOCKER_CACHE_REGISTRY', docker.cache.registry); core.exportVariable('DOCKER_CACHE_NAME', docker.cache.name); core.exportVariable('DOCKER_CACHE_GRYPE', docker.cache.grype); core.exportVariable('DOCKER_IMAGE_NAME', docker.image.name); core.exportVariable('DOCKER_IMAGE_ARCH', docker.image.arch); core.exportVariable('DOCKER_IMAGE_TAGS', docker.tags.join(',')); core.exportVariable('DOCKER_IMAGE_DESCRIPTION', docker.image.description); core.exportVariable('DOCKER_IMAGE_ARGUMENTS', arguments.join("\r\n")); core.exportVariable('DOCKER_IMAGE_DOCKERFILE', opt.input?.etc?.dockerfile || 'arch.dockerfile'); core.exportVariable('WORKFLOW_BUILD', (opt.input?.build === undefined) ? false : opt.input.build); core.exportVariable('WORKFLOW_CREATE_RELEASE', (opt.input?.release === undefined) ? false : opt.input.release); core.exportVariable('WORKFLOW_CREATE_README', (opt.input?.readme === undefined) ? false : opt.input.readme); core.exportVariable('WORKFLOW_GRYPE_FAIL_ON_SEVERITY', (opt.dot?.grype?.fail === undefined) ? true : opt.dot.grype.fail); core.exportVariable('WORKFLOW_GRYPE_SEVERITY_CUTOFF', (opt.dot?.grype?.severity || 'high')); if(opt.dot?.readme?.comparison){ core.exportVariable('WORKFLOW_CREATE_COMPARISON', true); core.exportVariable('WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE', opt.dot.readme.comparison.image); core.exportVariable('WORKFLOW_CREATE_COMPARISON_IMAGE', `${docker.image.name}:${docker.app.version}`); } # DOCKER - name: docker / login to hub uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 with: username: 11notes password: ${{ secrets.DOCKER_TOKEN }} - name: github / login to ghcr uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 with: registry: ghcr.io username: 11notes password: ${{ secrets.GITHUB_TOKEN }} - name: quay / login to quay uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 with: registry: quay.io username: 11notes+github password: ${{ secrets.QUAY_TOKEN }} - name: docker / setup qemu if: env.WORKFLOW_BUILD == 'true' uses: docker/setup-qemu-action@53851d14592bedcffcf25ea515637cff71ef929a - name: docker / setup buildx if: env.WORKFLOW_BUILD == 'true' uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 with: driver-opts: network=host - name: docker / build & push & tag grype if: env.WORKFLOW_BUILD == 'true' id: docker-build uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d with: context: . file: ${{ env.DOCKER_IMAGE_DOCKERFILE }} push: true platforms: ${{ env.DOCKER_IMAGE_ARCH }} cache-from: type=registry,ref=${{ env.DOCKER_CACHE_NAME }} cache-to: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true build-args: | ${{ env.DOCKER_IMAGE_ARGUMENTS }} tags: | ${{ env.DOCKER_CACHE_GRYPE }} - name: grype / scan if: env.WORKFLOW_BUILD == 'true' id: grype uses: anchore/scan-action@dc6246fcaf83ae86fcc6010b9824c30d7320729e with: image: ${{ env.DOCKER_CACHE_GRYPE }} fail-build: ${{ env.WORKFLOW_GRYPE_FAIL_ON_SEVERITY }} severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }} output-format: 'sarif' by-cve: true cache-db: true - name: grype / fail if: env.WORKFLOW_BUILD == 'true' && (failure() || steps.grype.outcome == 'failure') uses: anchore/scan-action@dc6246fcaf83ae86fcc6010b9824c30d7320729e with: image: ${{ env.DOCKER_CACHE_GRYPE }} fail-build: false severity-cutoff: ${{ env.WORKFLOW_GRYPE_SEVERITY_CUTOFF }} output-format: 'table' by-cve: true cache-db: true - name: docker / build & push if: env.WORKFLOW_BUILD == 'true' uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d with: context: . file: ${{ env.DOCKER_IMAGE_DOCKERFILE }} push: true sbom: true provenance: mode=max platforms: ${{ env.DOCKER_IMAGE_ARCH }} cache-from: type=registry,ref=${{ env.DOCKER_CACHE_REGISTRY }}${{ env.DOCKER_CACHE_NAME }} cache-to: type=registry,ref=${{ env.DOCKER_CACHE_NAME }},mode=max,compression=zstd,force-compression=true build-args: | ${{ env.DOCKER_IMAGE_ARGUMENTS }} tags: | ${{ env.DOCKER_IMAGE_TAGS }} # RELEASE - name: github / release / log continue-on-error: true id: git-log run: | LOCAL_LAST_TAG=$(git describe --abbrev=0 --tags `git rev-list --tags --skip=1 --max-count=1`) echo "using last tag: ${LOCAL_LAST_TAG}" LOCAL_COMMITS=$(git log ${LOCAL_LAST_TAG}..HEAD --oneline) EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "commits<<${EOF}" >> ${GITHUB_OUTPUT} echo "${LOCAL_COMMITS}" >> ${GITHUB_OUTPUT} echo "${EOF}" >> ${GITHUB_OUTPUT} - name: github / release / markdown if: env.WORKFLOW_CREATE_RELEASE == 'true' && steps.git-log.outcome == 'success' id: git-release uses: 11notes/action-docker-release@v1 # WHY IS THIS ACTION NOT SHA256 PINNED? SECURITY MUCH?!?!?! # --------------------------------------------------------------------------------- # the next step "github / release / create" creates a new release based on the code # in the repo. This code is not modified and can't be modified by this action. # It does create the markdown for the release, which could be abused, but to what # extend? Adding a link to a malicious repo? with: git_log: ${{ steps.git-log.outputs.commits }} - name: github / release / create if: env.WORKFLOW_CREATE_RELEASE == 'true' && steps.git-release.outcome == 'success' uses: actions/create-release@4c11c9fe1dcd9636620a16455165783b20fc7ea0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} body: ${{ steps.git-release.outputs.release }} draft: false prerelease: false # LICENSE - name: license / update year continue-on-error: true uses: actions/github-script@62c3794a3eb6788d9a2a72b219504732c0c9a298 with: script: | const { existsSync, readFileSync, writeFileSync } = require('node:fs'); const { resolve } = require('node:path'); const file = 'LICENSE'; const year = new Date().getFullYear(); try{ const path = resolve(file); if(existsSync(path)){ let license = readFileSync(file).toString(); if(!new RegExp(`Copyright \\(c\\) ${year} 11notes`, 'i').test(license)){ license = license.replace(/Copyright \(c\) \d{4} /i, `Copyright (c) ${new Date().getFullYear()} `); writeFileSync(path, license); } }else{ throw new Error(`file ${file} does not exist`); } }catch(e){ core.setFailed(e); } # README - name: github / checkout HEAD continue-on-error: true run: | git checkout HEAD - name: docker / setup comparison images if: env.WORKFLOW_CREATE_COMPARISON == 'true' continue-on-error: true run: | docker image pull ${{ env.WORKFLOW_CREATE_COMPARISON_IMAGE }} docker image ls --filter "reference=${{ env.WORKFLOW_CREATE_COMPARISON_IMAGE }}" --format json | jq --raw-output '.Size' &> ./comparison.size0.log docker image pull ${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }} docker image ls --filter "reference=${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }}" --format json | jq --raw-output '.Size' &> ./comparison.size1.log docker run --entrypoint "/bin/sh" --rm ${{ env.WORKFLOW_CREATE_COMPARISON_FOREIGN_IMAGE }} -c id &> ./comparison.id.log - name: github / create README.md id: github-readme continue-on-error: true if: env.WORKFLOW_CREATE_README == 'true' uses: 11notes/action-docker-readme@v1 # WHY IS THIS ACTION NOT SHA256 PINNED? SECURITY MUCH?!?!?! # --------------------------------------------------------------------------------- # the next step "github / commit & push" only adds the README and LICENSE as well as # compose.yaml to the repository. This does not pose a security risk if this action # would be compromised. The code of the app can't be changed by this action. Since # only the files mentioned are commited to the repo. Sure, someone could make a bad # compose.yaml, but since this serves only as an example I see no harm in that. with: sarif_file: ${{ steps.grype.outputs.sarif }} build_output_metadata: ${{ steps.docker-build.outputs.metadata }} - name: docker / push README.md to docker hub continue-on-error: true if: steps.github-readme.outcome == 'success' && hashFiles('README_NONGITHUB.md') != '' uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 env: DOCKER_USER: 11notes DOCKER_PASS: ${{ secrets.DOCKER_TOKEN }} with: destination_container_repo: ${{ env.DOCKER_IMAGE_NAME }} provider: dockerhub short_description: ${{ env.DOCKER_IMAGE_DESCRIPTION }} readme_file: 'README_NONGITHUB.md' - name: github / commit & push continue-on-error: true if: steps.github-readme.outcome == 'success' && hashFiles('README.md') != '' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add README.md if [ -f compose.yaml ]; then git add compose.yaml fi if [ -f LICENSE ]; then git add LICENSE fi git commit -m "github-actions[bot]: update README.md" git push origin HEAD:master # REPOSITORY SETTINGS - name: github / update description and set repo defaults run: | curl --request PATCH \ --url https://api.github.com/repos/${{ github.repository }} \ --header 'authorization: Bearer ${{ secrets.REPOSITORY_TOKEN }}' \ --header 'content-type: application/json' \ --data '{ "description":"${{ env.DOCKER_IMAGE_DESCRIPTION }}", "homepage":"", "has_issues":true, "has_discussions":true, "has_projects":false, "has_wiki":false }' \ --fail