Skip to content

Commit bee2e09

Browse files
author
Kristian Larsson
committed
Add optional OCI output artifacts
Support optional Acton output artifacts fetched from OCI repositories during dependency resolution. Artifacts are keyed by source content hash, interface version, and target tuple. Use valid local cache entries before registry lookups and fall back to source when no matching artifact is available. Add artifact pack, push, and hash commands, local OCI layout support, tests, and guide documentation.
1 parent 7b421d3 commit bee2e09

11 files changed

Lines changed: 1119 additions & 53 deletions

File tree

compiler/acton/Main.hs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ main = do
133133
C.CmdOpt gopts (C.PkgUpgrade opts) -> PkgCommands.pkgUpgradeCommand gopts opts
134134
C.CmdOpt gopts C.PkgUpdate -> PkgCommands.pkgUpdateCommand gopts
135135
C.CmdOpt gopts (C.PkgSearch opts) -> PkgCommands.pkgSearchCommand gopts opts
136+
C.CmdOpt gopts (C.Artifact opts) -> artifactCommand gopts opts
136137
C.CmdOpt gopts (C.BuildSpecCmd o) -> buildSpecCommand o
137138
C.CmdOpt gopts (C.Cloud opts) -> undefined
138139
C.CmdOpt gopts (C.Doc opts) -> printDocs gopts opts
@@ -511,6 +512,24 @@ withProjectLockForGen gopts sched gen projDir action =
511512
withProjectLockNotice gopts projDir $
512513
whenCurrentGen sched gen action
513514

515+
artifactCommand :: C.GlobalOptions -> C.ArtifactCommand -> IO ()
516+
artifactCommand gopts cmd =
517+
case cmd of
518+
C.ArtifactHash _ ->
519+
PkgCommands.artifactCommand gopts cmd
520+
_ -> do
521+
let opts = defaultCompileOptions { C.skip_build = True }
522+
sp = Source.diskSourceProvider
523+
paths <- loadProjectPaths opts
524+
let projDir = projPath paths
525+
withProjectLockNotice gopts projDir $ do
526+
unless (C.quiet gopts) $
527+
putStrLn ("Building project in " ++ projDir)
528+
srcFiles <- projectSourceFiles paths
529+
compileFiles sp gopts opts srcFiles True
530+
generateProjectDocIndex sp gopts opts paths srcFiles
531+
PkgCommands.artifactCommand gopts cmd
532+
514533
requireProjectLayout :: Paths -> IO ()
515534
requireProjectLayout paths = do
516535
exists <- doesDirectoryExist (srcDir paths)
@@ -929,7 +948,7 @@ runWatchFile gopts absFile sched runOnce = do
929948
fetchCommand :: C.GlobalOptions -> IO ()
930949
fetchCommand gopts = do
931950
paths <- loadProjectPaths defaultCompileOptions
932-
res <- try (fetchDependencies gopts paths []) :: IO (Either ProjectError ())
951+
res <- try (fetchDependencies gopts paths [] []) :: IO (Either ProjectError ())
933952
case res of
934953
Left (ProjectError msg) -> printErrorAndExit msg
935954
Right () ->
@@ -955,7 +974,7 @@ sigCommand gopts sigOpts = do
955974
rootProj <- normalizePathSafe (projPath paths)
956975
sysAbs <- normalizePathSafe (sysPath paths)
957976
withProjectLockNotice queryGopts rootProj $ do
958-
fetchDependencies queryGopts paths depOverrides
977+
fetchDependencies queryGopts paths depOverrides (C.artifact_repos opts)
959978
projMap <- discoverProjects queryGopts sysAbs rootProj depOverrides
960979
target <- resolveSigTarget opts paths rootProj projMap (C.sigTarget sigOpts)
961980
tyFile <- case target of

compiler/acton/PkgCommands.hs

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module PkgCommands
77
, pkgUpgradeCommand
88
, pkgUpdateCommand
99
, pkgSearchCommand
10+
, artifactCommand
1011
, zigPkgAddCommand
1112
, zigPkgRemoveCommand
1213
, PackageEntry(..)
@@ -21,6 +22,7 @@ module PkgCommands
2122

2223
import Prelude hiding (readFile, writeFile)
2324

25+
import qualified Acton.Artifact as Artifact
2426
import qualified Acton.BuildSpec as BuildSpec
2527
import qualified Acton.CommandLineParser as C
2628
import Acton.Compile (loadBuildSpec, throwProjectError)
@@ -30,7 +32,7 @@ import Control.Concurrent (threadDelay)
3032
import Control.Monad (filterM, forM, forM_, unless, when)
3133
import Data.Char (isHexDigit, isSpace)
3234
import Data.Foldable (toList)
33-
import Data.List (dropWhileEnd, isPrefixOf, isSuffixOf, sortOn)
35+
import Data.List (dropWhileEnd, isPrefixOf, isSuffixOf, sort, sortOn)
3436
import Data.List.Split (splitOn)
3537
import Data.Maybe (isJust)
3638
import qualified Data.Map as M
@@ -44,11 +46,12 @@ import Network.HTTP.Client (Manager, Response, httpLbs, parseRequest, requestHea
4446
import Network.HTTP.Client.TLS (newTlsManager)
4547
import Network.HTTP.Types.Header (Header)
4648
import Network.HTTP.Types.Status (statusCode)
47-
import System.Directory (Permissions, canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, doesPathExist, getCurrentDirectory, getHomeDirectory, getPermissions, listDirectory, removeFile, setPermissions)
49+
import System.Directory (Permissions, canonicalizePath, copyFile, copyFileWithMetadata, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, doesPathExist, getCurrentDirectory, getHomeDirectory, getPermissions, listDirectory, makeAbsolute, pathIsSymbolicLink, removeFile, setPermissions)
4850
import System.Environment (getExecutablePath, lookupEnv)
4951
import System.Exit (ExitCode(..))
5052
import System.FilePath ((</>), takeDirectory)
5153
import System.IO (IOMode(ReadMode, WriteMode), hClose, hGetContents, hPutStr, hPutStrLn, hSetEncoding, openFile, stderr, utf8)
54+
import System.IO.Temp (withSystemTempDirectory)
5255
import System.Process (CreateProcess(cwd), proc, readCreateProcessWithExitCode)
5356
import qualified Text.Regex.TDFA as TDFA
5457

@@ -303,6 +306,179 @@ pkgSearchCommand _ opts = do
303306
then putStrLn "No packages matched your search."
304307
else forM_ (sortOn pkgName matched) printPkg
305308

309+
artifactCommand :: C.GlobalOptions -> C.ArtifactCommand -> IO ()
310+
artifactCommand gopts cmd =
311+
case cmd of
312+
C.ArtifactPack opts ->
313+
packActonArtifact gopts (C.artifactPackOutput opts)
314+
C.ArtifactPush opts ->
315+
pushActonArtifact gopts opts
316+
C.ArtifactHash opts ->
317+
printActonArtifactHash (C.artifactHashSourcePath opts)
318+
319+
packActonArtifact :: C.GlobalOptions -> String -> IO ()
320+
packActonArtifact gopts outputArg = do
321+
sourceHash <- computeArtifactSourceHash "."
322+
output <- packActonArtifactTo sourceHash outputArg
323+
unless (C.quiet gopts) $
324+
putStrLn ("Wrote Acton artifact " ++ output)
325+
326+
pushActonArtifact :: C.GlobalOptions -> C.ArtifactPushOptions -> IO ()
327+
pushActonArtifact gopts opts = do
328+
sourceHash <- computeArtifactSourceHash "."
329+
ref0 <- resolveArtifactRef sourceHash
330+
(C.artifactPushRepoUrl opts)
331+
(C.artifactPushArtifactRepo opts)
332+
ref <- absolutizeLocalArtifactRef ref0
333+
withSystemTempDirectory "acton-artifact-push" $ \tmp -> do
334+
let archive = tmp </> Artifact.artifactArchiveFile
335+
_ <- packActonArtifactTo sourceHash archive
336+
runProcessChecked (Just tmp) "oras"
337+
(["push"]
338+
++ Artifact.ociRefOrasOptions ref
339+
++ [ "--artifact-type", Artifact.artifactType
340+
, Artifact.ociRefOrasTarget ref
341+
, Artifact.artifactArchiveFile ++ ":" ++ Artifact.artifactMediaType
342+
])
343+
unless (C.quiet gopts) $
344+
putStrLn ("Pushed Acton artifact " ++ ref)
345+
346+
packActonArtifactTo :: String -> String -> IO FilePath
347+
packActonArtifactTo sourceHash outputArg = do
348+
cwd <- getCurrentDirectory
349+
let output0 = if null outputArg then "out" </> Artifact.artifactArchiveFile else outputArg
350+
createDirectoryIfMissing True (takeDirectory output0)
351+
withSystemTempDirectory "acton-artifact-pack" $ \tmp -> do
352+
Artifact.writeManifest tmp (Artifact.expectedManifest sourceHash)
353+
runProcessChecked Nothing "tar"
354+
[ "-czf", output0
355+
, "-C", tmp, Artifact.artifactManifestFile
356+
, "-C", cwd, "Build.act", "out/types"
357+
]
358+
return output0
359+
360+
printActonArtifactHash :: String -> IO ()
361+
printActonArtifactHash source = do
362+
h <- computeArtifactSourceHash source
363+
putStrLn h
364+
365+
computeArtifactSourceHash :: FilePath -> IO String
366+
computeArtifactSourceHash source = do
367+
isDir <- doesDirectoryExist source
368+
unless isDir $
369+
throwProjectError ("ERROR: Artifact source hash expects a package directory: " ++ source)
370+
zigExe <- getZigExe
371+
withSystemTempDirectory "acton-artifact-source" $ \tmp -> do
372+
let staged = tmp </> "source"
373+
-- TODO(source-hash): Avoid staging a copied package tree here.
374+
--
375+
-- This copy is intentionally simple and conservative, but source hashing is
376+
-- foundational identity machinery and should eventually be computed directly
377+
-- from the package directory. The desired implementation is an Acton-owned
378+
-- package hash walker that follows Zig's package hashing semantics exactly
379+
-- rather than invoking `zig fetch` on a temporary copy.
380+
--
381+
-- Requirements for that replacement:
382+
--
383+
-- * Keep the source boundary independent of git or any other VCS.
384+
-- * Preserve the current Acton package selection rules: include ordinary
385+
-- package inputs, exclude local/generated state such as out/, build.zig,
386+
-- build.zig.zon, lock files, VCS dirs, caches, and artifact files.
387+
-- * Match Zig's path normalization, directory ordering, file metadata/mode
388+
-- treatment, digest algorithm, and final package-hash text encoding.
389+
-- * Keep symlink behavior explicit. Today symlinks are rejected rather than
390+
-- guessed; any future support must match Zig and have test coverage.
391+
-- * Add stable fixture tests that compare Acton's in-place implementation
392+
-- with `zig fetch` for representative package trees before switching over.
393+
--
394+
-- Until then, copying to a clean temp directory keeps generated build output
395+
-- out of the hash while delegating the actual hash algorithm to Zig.
396+
copyCanonicalSource source staged
397+
requireRightWith "ERROR: Failed to compute source hash: " =<< zigFetchHash zigExe staged
398+
399+
copyCanonicalSource :: FilePath -> FilePath -> IO ()
400+
copyCanonicalSource src dst =
401+
copyCanonicalSourceAt [] src dst
402+
403+
copyCanonicalSourceAt :: [FilePath] -> FilePath -> FilePath -> IO ()
404+
copyCanonicalSourceAt rel src dst = do
405+
createDirectoryIfMissing True dst
406+
entries <- sort <$> listDirectory src
407+
forM_ entries $ \entry ->
408+
let rel' = rel ++ [entry]
409+
in unless (skipLocalSourceEntry rel') $ do
410+
let srcEntry = src </> entry
411+
dstEntry = dst </> entry
412+
isSymlink <- pathIsSymbolicLink srcEntry
413+
when isSymlink $
414+
throwProjectError ("ERROR: Artifact source hash does not support symbolic links: " ++ srcEntry)
415+
isDir <- doesDirectoryExist srcEntry
416+
if isDir
417+
then copyCanonicalSourceAt rel' srcEntry dstEntry
418+
else do
419+
createDirectoryIfMissing True (takeDirectory dstEntry)
420+
copyFileWithMetadata srcEntry dstEntry
421+
422+
skipLocalSourceEntry :: [FilePath] -> Bool
423+
skipLocalSourceEntry [] = False
424+
skipLocalSourceEntry rel =
425+
entry `elem`
426+
[ ".git"
427+
, ".hg"
428+
, ".svn"
429+
, ".DS_Store"
430+
, ".zig-cache"
431+
, "zig-cache"
432+
] ||
433+
rel `elem`
434+
[ [".acton.lock"]
435+
, [".acton.compile.lock"]
436+
, [".acton"]
437+
, [".acton.cache"]
438+
, [".actonc.lock"]
439+
, [".build"]
440+
, ["out"]
441+
, ["build.zig"]
442+
, ["build.zig.zon"]
443+
, [Artifact.artifactManifestFile]
444+
, [Artifact.artifactArchiveFile]
445+
]
446+
where
447+
entry = last rel
448+
449+
absolutizeLocalArtifactRef :: String -> IO String
450+
absolutizeLocalArtifactRef ref
451+
| Artifact.ociRefIsLocal ref =
452+
case splitLocalOciTarget (Artifact.ociRefOrasTarget ref) of
453+
Just (path, tag) -> do
454+
path' <- makeAbsolute path
455+
return ("oci-layout://" ++ path' ++ ":" ++ tag)
456+
Nothing -> return ref
457+
| otherwise = return ref
458+
459+
splitLocalOciTarget :: String -> Maybe (FilePath, String)
460+
splitLocalOciTarget target =
461+
case break (== ':') (reverse target) of
462+
(revTag, ':' : revPath)
463+
| not (null revTag) && not (null revPath) ->
464+
Just (reverse revPath, reverse revTag)
465+
_ -> Nothing
466+
467+
resolveArtifactRef :: String -> String -> String -> IO String
468+
resolveArtifactRef sourceHash repoUrl artifactRepo
469+
| not (null artifactRepo) =
470+
case Artifact.ociRefForRepository artifactRepo sourceHash of
471+
Just ref -> return ref
472+
Nothing ->
473+
throwProjectError ("ERROR: Invalid OCI artifact repository " ++ artifactRepo)
474+
| not (null repoUrl) =
475+
case Artifact.deriveOciRef repoUrl sourceHash of
476+
Just ref -> return ref
477+
Nothing ->
478+
throwProjectError ("ERROR: Could not derive OCI artifact ref from " ++ repoUrl)
479+
| otherwise =
480+
throwProjectError "ERROR: Specify --artifact-repo or --repo-url for artifact push"
481+
306482
zigPkgAddCommand :: C.GlobalOptions -> C.ZigPkgAddOptions -> IO ()
307483
zigPkgAddCommand _ opts = do
308484
let depName = C.zigPkgAddName opts

compiler/acton/test.hs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,24 @@ parseFlagTests =
450450
assertBool "serial parser flag should be set" (C.parse_serial (C.buildCompile buildOpts))
451451
_ ->
452452
assertFailure "expected build command"
453+
, testCase "build parser accepts artifact repositories" $ do
454+
parsed <- parseArgs ["build", "--artifact-repo", "local-registry.local-domain.com/acton-out"]
455+
case parsed of
456+
C.CmdOpt _ (C.Build buildOpts) ->
457+
assertEqual "artifact repos"
458+
["local-registry.local-domain.com/acton-out"]
459+
(C.artifact_repos (C.buildCompile buildOpts))
460+
_ ->
461+
assertFailure "expected build command"
462+
, testCase "artifact parser accepts top-level push command" $ do
463+
parsed <- parseArgs ["artifact", "push", "--artifact-repo", "registry.local/acton-out"]
464+
case parsed of
465+
C.CmdOpt _ (C.Artifact (C.ArtifactPush opts)) -> do
466+
assertEqual "artifact repo" "registry.local/acton-out" (C.artifactPushArtifactRepo opts)
467+
_ ->
468+
assertFailure "expected artifact push command"
469+
, testCase "artifact parser rejects raw push refs" $ do
470+
assertParseFails ["artifact", "push", "--ref", "registry.local/acton-out:anything"]
453471
, testCase "build parser help includes --release alias" $ do
454472
helpText <- renderParserHelp ["build", "--help"]
455473
assertBool "help text should include --release" ("--release" `isInfixOf` helpText)
@@ -718,6 +736,13 @@ parseFlagTests =
718736
OA.CompletionInvoked _ ->
719737
assertFailure ("parser requested shell completion for " ++ unwords args)
720738

739+
assertParseFails args =
740+
case OA.execParserPure OA.defaultPrefs parserInfo args of
741+
OA.Failure _ -> return ()
742+
OA.Success _ -> assertFailure ("parser unexpectedly accepted " ++ unwords args)
743+
OA.CompletionInvoked _ ->
744+
assertFailure ("parser requested shell completion for " ++ unwords args)
745+
721746
assertParsedBuildOptimize args expected = do
722747
parsed <- parseArgs args
723748
case parsed of
@@ -1004,8 +1029,10 @@ actonProjTests =
10041029
let srcDir = proj </> "src"
10051030
buildAct = proj </> "Build.act"
10061031
mainAct = srcDir </> "main.act"
1032+
artifactDep = proj </> "deps" </> "artifact_dep"
10071033
createDirectoryIfMissing True srcDir
10081034
createDirectoryIfMissing True (proj </> "deps")
1035+
createDirectoryIfMissing True (artifactDep </> "out" </> "types")
10091036
writeFile buildAct $ unlines
10101037
[ "name = \"invalid_dep_override\""
10111038
, "fingerprint = 0xb33bef4512345678"
@@ -1019,12 +1046,36 @@ actonProjTests =
10191046
, " print(\"hello\")"
10201047
, " env.exit(0)"
10211048
]
1049+
writeFile (artifactDep </> "Build.act") $ unlines
1050+
[ "name = \"artifact_dep\""
1051+
, "fingerprint = 0xa44bef4512345678"
1052+
, "dependencies = {}"
1053+
, "zig_dependencies = {}"
1054+
]
1055+
writeFile (artifactDep </> "acton-artifact.json") "{}"
10221056
actonExe <- canonicalizePath "../../dist/bin/acton"
10231057
(returnCode, _cmdOut, cmdErr) <- readCreateProcessWithExitCode (proc actonExe ["build", "--dep", "dep_a=deps"]){ cwd = Just proj } ""
10241058
assertEqual "acton should fail for invalid --dep path" (ExitFailure 1) returnCode
10251059
assertBool "error should mention bad dependency path" ("Dependency dep_a path is not an Acton project root" `isInfixOf` cmdErr)
10261060
assertBool "error should mention required project files" ("Build.act" `isInfixOf` cmdErr)
10271061
assertBool "error should mention src requirement" ("src/" `isInfixOf` cmdErr)
1062+
(artifactReturnCode, _artifactOut, artifactErr) <- readCreateProcessWithExitCode
1063+
(proc actonExe ["build", "--dep", "dep_a=deps/artifact_dep"]){ cwd = Just proj } ""
1064+
assertEqual "acton should reject artifact roots as --dep paths" (ExitFailure 1) artifactReturnCode
1065+
assertBool "artifact root should still be rejected as a local source dep"
1066+
("Dependency dep_a path is not an Acton project root" `isInfixOf` artifactErr)
1067+
writeFile buildAct $ unlines
1068+
[ "name = \"invalid_dep_override\""
1069+
, "fingerprint = 0xb33bef4512345678"
1070+
, ""
1071+
, "dependencies = {"
1072+
, " \"dep_a\": (path=\"deps/artifact_dep\")"
1073+
, "}"
1074+
]
1075+
(pathReturnCode, _pathOut, pathErr) <- readCreateProcessWithExitCode (proc actonExe ["build"]){ cwd = Just proj } ""
1076+
assertEqual "acton should reject artifact roots as Build.act paths" (ExitFailure 1) pathReturnCode
1077+
assertBool "path error should point users to artifact repos"
1078+
("Use --artifact-repo to search output artifacts" `isInfixOf` pathErr)
10281079

10291080
-- Verify pruning keeps binaries for modules that still have roots across build / test runs.
10301081
, testCase "executable pruning" $ do

compiler/lib/package.yaml.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ dependencies:
6161
library:
6262
source-dirs: src
6363
exposed-modules:
64+
- Acton.Artifact
6465
- Acton.Boxing
6566
- Acton.BuildSpec
6667
- Acton.Builtin

0 commit comments

Comments
 (0)